优化 IconList 图标组件
优化目标
上一节完成的 IconList 组件将所有逻辑(展示、复制、状态管理)写在一个文件中。本节进行以下优化:
- 组件与页面分离:将通用 IconList 组件与业务页面拆分
- Props 参数化:图标集、数据、样式全部通过 Props 配置
- 事件回调化:点击行为由父组件通过 emit 控制
- 路由组织:按业务功能划分页面路由
目录结构调整
src/
├── components/
│ ├── Icons/
│ │ ├── IconList.vue # 通用图标列表组件
│ │ └── types.ts # 类型定义
│ ├── NetIcon/
│ │ └── NetIcon.vue
│ └── SvgIcon.vue
├── pages/
│ └── components/
│ └── icons/
│ └── icon-list.vue # 业务页面:展示 IconList
└── assets/
└── icons/
└── icon-ep.json # Element Plus 图标名称数据
text
类型定义
// src/components/Icons/types.ts
export interface IconListProps {
/** 图标集前缀,如 'ep', 'mdi', 'carbon' */
collection?: string
/** 图标名称数组 */
iconData?: string[]
/** 是否显示图标名称文字 */
showText?: boolean
/** 图标元素的 class(控制大小等) */
iconClass?: string
/** 每个图标项的 class(控制宽高、边框等) */
itemClass?: string
}
export interface IconListEmits {
(e: 'click', name: string): void
}
typescript
通用 IconList 组件
<!-- src/components/Icons/IconList.vue -->
<script setup lang="ts">
import { onBeforeMount } from 'vue'
import { Icon, loadIcons } from '@iconify/vue'
import type { IconListProps } from './types'
import iconData from '@/assets/icons/icon-ep.json'
const props = withDefaults(defineProps<IconListProps>(), {
collection: 'ep',
iconData: () => iconData,
showText: true,
iconClass: 'text-3xl',
itemClass: 'py-4 w-[120px] h-[100px] flex flex-col items-center justify-center border-r border-b border-gray-200 cursor-pointer hover:bg-sky-50 transition-colors duration-200',
})
const emit = defineEmits<{
click: [name: string]
}>()
// 预加载图标数据
onBeforeMount(async () => {
if (props.iconData?.length) {
const icons = props.iconData.map(
(name) => `${props.collection}:${name}`
)
await loadIcons(icons)
}
})
// 短横线转驼峰
function convertString(str: string): string {
return str
.split('-')
.map((word, index) =>
index === 0
? /^[0-9]/.test(word) ? `_${word}` : word
: word.charAt(0).toUpperCase() + word.slice(1)
)
.join('')
}
</script>
<template>
<ul class="flex flex-wrap border border-gray-200 rounded">
<li
v-for="(name, index) in iconData"
:key="index"
:class="itemClass"
@click="emit('click', name)"
>
<Icon
:icon="`${collection}:${name}`"
:class="iconClass"
/>
<span
v-if="showText"
class="text-xs text-gray-500 mt-1 truncate w-full text-center px-1"
>
{{ convertString(name) }}
</span>
</li>
</ul>
</template>
vue
业务页面
<!-- src/pages/components/icons/icon-list.vue -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import { loadIcon } from '@iconify/vue'
import { useClipboard } from '@vueuse/core'
import { ElMessage, ElSwitch } from 'element-plus'
import EpIconList from '@/components/Icons/IconList.vue'
import iconData from '@/assets/icons/icon-ep.json'
// 复制状态
const source = ref('')
const { copy, copied } = useClipboard({ source })
// 复制模式控制
const copyTypeFlag = ref(true) // true=名称, false=SVG
const showTextFlag = ref(true) // 显示/隐藏文字
const copyIconComponentFlag = ref(false) // 复制组件代码
// 短横线转驼峰
function convertString(str: string): string {
return str
.split('-')
.map((word, index) =>
index === 0
? /^[0-9]/.test(word) ? `_${word}` : word
: word.charAt(0).toUpperCase() + word.slice(1)
)
.join('')
}
// SVG 对象转字符串
function objectToString(svgObj: any): string {
if (!svgObj) return ''
const { body, width = 24, height = 24 } = svgObj
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${body}</svg>`
}
// 处理图标点击
async function handleClick(name: string) {
if (copyIconComponentFlag.value) {
// 复制组件代码
source.value = `<Icon icon="ep:${name}" />`
} else if (copyTypeFlag.value) {
// 复制驼峰名称
source.value = convertString(name)
} else {
// 复制 SVG 内容
const data = await loadIcon(`ep:${name}`)
if (data) {
source.value = objectToString(data)
}
}
copy(source.value)
}
// 监听复制成功
watch(copied, (val) => {
if (val) {
ElMessage.success('复制成功')
}
})
</script>
<template>
<div class="p-6">
<h2 class="text-2xl font-bold mb-6">Element Plus 图标列表</h2>
<!-- 控制栏 -->
<div class="flex flex-wrap items-center gap-4 mb-4">
<div class="flex items-center gap-2">
<span class="text-sm">复制名称</span>
<ElSwitch v-model="copyTypeFlag" />
<span class="text-sm">复制 SVG</span>
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="copyIconComponentFlag" type="checkbox" />
复制 Icon 组件代码
</label>
<label class="flex items-center gap-2 text-sm">
<input v-model="showTextFlag" type="checkbox" />
显示文字
</label>
</div>
<!-- 图标列表组件 -->
<EpIconList
collection="ep"
:icon-data="iconData"
:show-text="showTextFlag"
@click="handleClick"
/>
</div>
</template>
vue
组件设计要点
Props 设计原则
| Prop | 类型 | 默认值 | 说明 |
|---|---|---|---|
collection | string | 'ep' | 图标集前缀,支持切换不同图标集 |
iconData | string[] | iconData | 图标名称数组,支持外部传入自定义数据 |
showText | boolean | true | 是否显示图标名称 |
iconClass | string | 'text-3xl' | 图标大小样式,可覆盖 |
itemClass | string | (完整样式串) | 每个图标项的容器样式 |
默认值注意事项
数组类型的 Props 必须使用工厂函数返回默认值:
// 正确写法
iconData: () => iconData
// 错误写法(会导致多实例共享引用)
iconData: iconData
typescript
动态拼接图标名
图标名称需要拼接 collection 前缀:
// 在 loadIcons 和 Icon 组件中都要拼接
const iconName = `${props.collection}:${name}`
// 如 'ep:home', 'mdi:account'
typescript
路由配置
使用文件系统路由自动生成,页面放在 src/pages/components/icons/ 目录下:
src/pages/
└── components/
└── icons/
└── icon-list.vue → /components/icons/icon-list
text
访问 http://localhost:5173/components/icons/icon-list 即可查看。
组件命名冲突解决
当页面组件名与引用的组件名相同时(如都叫 IconList),会导致递归渲染。解决方案:
- 重命名页面组件(如
EpIconList作为 import 别名) - 使用不同的文件/目录名称
- 重启开发服务器让 auto-import 重新生成组件映射
// 使用 import 别名避免冲突
import EpIconList from '@/components/Icons/IconList.vue'
typescript
优化前后对比
| 方面 | 优化前 | 优化后 |
|---|---|---|
| 组件复用性 | 无法复用,所有逻辑耦合在页面中 | 通用组件,支持多图标集 |
| 事件处理 | 组件内部处理复制逻辑 | 通过 emit 回调,父组件控制 |
| 数据来源 | 硬编码 iconData | Props 传入,支持自定义 |
| 样式控制 | 固定样式 | iconClass / itemClass 可配置 |
| 路由组织 | 所有示例在 index 页面 | 按业务功能划分独立路由 |
| 扩展性 | 难以扩展 | 支持不同图标集、样式、回调 |
下一步:IconPicker
基于优化后的 IconList 组件,下一节将实现 IconPicker -- 一个包含弹窗、搜索、确认等完整交互的图标选择器组件。
↑